昨天說這個系統真的超賣過一次。所有人的直覺都會猜是鎖沒上好——不是。公式從頭到尾沒破,破的是公式裡一個詞的定義。
這個系統真的超賣過一次。所有人的直覺都會猜是鎖沒上好——不是。公式從頭到尾沒破,破的是公式裡一個詞的定義。
原設計裡,只有直播下單的購物車佔賣出數量;商城(平台)加入的購物車是意向,不佔。後來需求方希望商城購物車也算進賣出數量——改起來不難,改完跑一次 migration,把歷史的商城購物車全部納入計數。然後:賣出數量瞬間暴增,直接衝破庫存上限。

當年的收拾很務實:新語意保留下來——業務要的就是它;溢出的量兩條路消化——跟廠商追貨、把上限抬上去,追不到的由客服清購物車、逐一跟客人說明。一個從上限端修、一個從佔用端修,把帳擠回不變量裡。代購的地形這裡幫了忙:上限本來就不是倉庫裡的死數字,是「跟廠商還要得到多少」的承諾——有得談。
事後拆解,這裡有兩個獨立的錯誤疊在一起:
我重來的立場很簡單:這個需求我會踩死。 如果真的頂不住,也只有兩個安全檔位——新語意只對新資料生效(不 migrate);或商城佔用另設配額與時效,並且上線前先 dry-run 算出每個商品的計數會變成多少。「直接改+migrate」是唯一絕對錯誤的選項,而它恰恰是最順手的那個。這條教訓值得放大成一句話:不變量不是程式碼,是語意契約——改公式裡任何一個詞的定義,都是在重寫歷史資料的意義。
佔了就要放,不然庫存會慢性失血。當年的釋放不是 TTL 倒數,是結算日:直播以一週一個檔期為購買週期,檔期結束打一支「結束檔期 API」——清掉檔期下的購物車、未付款的訂單,並依條件把不付款的人加入黑名單(懲罰在週期邊界執行,是下一檔期的門票管制)。這支一次清一整檔的 API,當年跑起來意外地順——回頭看是紀律的回報:除了那次量測後的精準反正規化(上一章的 fb_user_id),整個 schema 乖乖遵守 3NF,大掃除時沒有散落各處的派生資料要跟著擦。正規化平常看不出好處,在大規模刪除和結算的那一刻連本帶利還你。
另外兩道防線:
上限 = 初始 + SUM(調整),追加寫入永遠是 insert 新列、不碰熱列,而且自帶「誰、何時、加了多少」的 audit——後台章要的操作留痕,順便解決。這章最重要的教訓,是超賣事故給的:守住不變量的最大威脅不是併發、不是 bug,是一個聽起來很合理的需求變更。「商城購物車也算賣出」在會議室裡毫無殺氣,沒有人覺得自己正在動一條鐵律的定義。工程師在這種時刻的職責,不是評估「改起來多難」——是認出這個需求在改語意,不是在加功能,然後把後果攤開來:要嘛不改,要嘛只對新資料生效,要嘛先 dry-run 給大家看數字。當年我們把它當一般需求做了;重來,這是我會用力說「不」的少數時刻。資深與否的差別,常常不在會多少技術,在認得出哪些字不能隨便改。
把這個系統的資料模型排開:留言先落地再消費、上限與消耗分開存、付款是新增 order item 而不是改 cart item、重來版的追加貨是 append 調整帳、每小時從事實重算派生值——事實只增不改,派生隨時可重建。當年沒有人說過「我們來做 event sourcing」,這些設計是被一個個具體的痛(帳對不上、熱列打架、除錯沒線索)逼出來的。DDIA 第三部分講的那套「事實與派生」,在一個六人團隊的電商系統裡自己長了出來——好的架構模式不是拿來套的,是誠實面對資料語意之後,自然收斂到的形狀。
明天講單怎麼從購物車走到訂單——跨檔期合併結帳的三層結構。
本文改寫自我的部落格系列《Re:從零開始做直播代購電商平台》,本篇完整版:https://blog.aidan.tw/blog/rezero-inventory/